Hacker News new | ask | show | jobs
by jasode 1976 days ago
>It's designed to force people to handle the damn error as near to the call as possible.

But that is sometimes the wrong design.

If you have functions A() --> call B() --> call C() ... and C() has an error because of a memory allocation failure or a network connection being down, sometimes the best context to handle that error is the outermost function A() and not C().

That's why some programmers don't like copypasting a bunch of "if err != nil {return err}" boilerplate across layers when the intentional semantic design is to deliberately autopropagate errors up the stack. E.g. function A() might have more knowledge of the state of the world via code logic to decide whether to retry a broken network connection or simply log the error and exit.

Sometimes handling the error is orthogonal to how a nested call tree is structured. It depends.

7 comments

Well yeah, but that's a situation that C() cannot predict; it's an unexpected error. 99% of Go's errors are expected errors that can and should be handled - you mention a network connection being down, that's an expected outcome when doing anything related to a network.

A memory allocation failure is unexpected, and more down to the OS than the application itself; that's where a panic is in order and a last moment "something serious has happened".

In theory, Java's exception handling is supposed to do the same; checked exceptions for expected errors, unchecked for left-field things.

Anyway that aside, Go's error handling could be better because unlike e.g. the Either pattern, you're not actually required to handle errors and using _ you can easily ignore them. Second, the code style and conventions seem to tell you to just re-use an `err` variable if there's multiple errors that can occur in a function (common in e.g. file handling), which opens up the way for accidentally not checking and handling an error.

The convention is to reuse err after you’ve handled any previous non-nil values. That does leave room for forgetting a check. I’ve made that mistake many times, but the errcheck linter finds the mistake every time.
This is all true. But there are static checkers to find these issues, it's not a huge deal. Having the power to ignore the convention when I want to is good :)
> But that is sometimes the wrong design.

I'd say that's always the wrong design, with a few exceptions that people can expect to find only a few times on their careers.

The entire point of exceptions was to pop the errors up on the stack until you get into a level where you can treat them. The entire reason they were created was because C-style error handling consists nearly all of code popping the errors up, what made C code very hard to read. The great revolution of error handling monads was that they made popping the errors up not require extra code, thus getting the same advantage as exceptions.

Nowadays I suspecct exception hierarchies was a mistake, and that the only reasonable way to have exceptions is to have them explicit. The monadic handling normally does not copy this hierarchy and is always explicit, what makes pokemon handlers something people must go out of their way to create, instead of being the only reliable way to catch them. But going back to the C-style isn't even only reverting minor gains and keeping the large ones, the large gain is handling the errors on the correct place, that Go throws away, the minor gains are verifying things at compile time and making sure the developer knows what errors he is dealing with, that Go takes a modern take.

What if we designed a system where there are two kinds of failures that can be returned? One where the caller is forced to address it by the compiler, and one that is transparent to the caller, but can be caught and addressed by anyone in the call stack (probably the top level)?

And one could convert one type of failure to the other. So if you call a library function and it returns the force-you-to-address kind of error, we could determine that we can't actually handle it at the call site, and just convert it to the invisible kind and let it keep going up.

The force-you-to-address it kind is enforced by the compiler. The compiler forces you to check if the function fails. A "checked failure"? "Checked error"? Hmm.

I think everyone has had this distinction on their mind when designing error handling in the past thirty years or so, it’s just that figuring out an ergonomic way to express it is quite hard.

In some languages the distinction is between logic errors and runtime errors. In Java, checked and unchecked exceptions. In Go, err and panic. Rust also has Err() and panic!().

If you look at, say, the evolution of the “if” statement, it was a number of years before this “obvious” control structure was added to programming languages. So there might be something similarly obvious for error handling, we just haven’t figured it out yet.

For sure. I didn't mean to imply that this isn't a legitimately hard problem for language designers. Just being a smart ass.

I do think that checked and unchecked exceptions are the right way. The issues that people have with Java's checked exceptions are mostly centered around Java's particular implementation of the concept. The biggest failure of which, IMO, is that you can't write an interface that is generic over the exception type. Also, wrapping in try {} catch {} finally {} is cumbersome. But Java is just cumbersome. In some expression-oriented language, it could be smooth. `try` could become an expression that returns a value. Or you could have syntax help like something Rust-ish: `val thing = fallible().finally { cleanUp() }?`.

That's the main reason, IMO, people don't complain quite as much about Rust's Result<T, E>, which is very much like a checked exception mechanism in spirit. The only problem with the Rust approach is that you have an extra if-statement on every single call to a fallible function, to unwrap the success/failure. If it used exceptions, the happy paths would (sometimes) be more optimized, if I understand correctly.

But some things just can't be fixed at the language level. You have to craft good error types and messages. You have to think through your happy paths as well as your sad paths. I like when languages force you to think about failure. I don't like when languages only have unchecked exceptions for all kinds of failures.

> The only problem with the Rust approach is that you have an extra if-statement on every single call to a fallible function, to unwrap the success/failure. If it used exceptions, the happy paths would (sometimes) be more optimized, if I understand correctly.

This is an implementation detail, and I’m not saying that lightly—I would not be surprised if future versions of Rust eliminated the conditional, because similar optimizations have been made in e.g. Haskell, and Rust has done some interesting work to optimize the run-time representation of enum types in the past to make them work the way you would expect the equivalent C types to work (e.g. Option<&X>).

> If you look at, say, the evolution of the “if” statement, it was a number of years before this “obvious” control structure was added to programming languages

As described [0] and discussed [1] a few weeks ago. Fascinating.

[0] https://github.com/ericfischer/if-then-else/blob/master/if-t...

[1] https://news.ycombinator.com/item?id=25406211

My irony detector is buzzing. You just described err vs. panic in go.
Checked vs unchecked exceptions in Java.
IMO the only way for this to actually work is to have the language/compiler force "exceptions" to be part of function signature. I am not aware of any mainstream language which does this.

Without compiler support ANY call can end up throwing an exception and thus ANY call can end up not returning (jumping straight to handler higher up in the call stack).

> IMO the only way for this to actually work is to have the language/compiler force "exceptions" to be part of function signature. I am not aware of any mainstream language which does this.

Java. Also Swift.

Rust also counts if you squint and call Result a checked exception and panic an unchecked exception. It's a little different because you technically can ignore a returned Result- it's just a compiler warning, rather than an error. Similar for Haskell and OCaml.

> memory allocation failure or a network connection being down, sometimes the best context to handle that error is the outermost function A() and not C().

If you need the memory (or disk space) to do something, what else can you really do but wait for memory to be available? The system might just be busy, or the user might have some files they can move if prompted (multitasking systems are the norm these days!). There exists a chance memory starvation is the result of contention, in which case someone needs to give up, rollback and try again (i.e. the B() in your example), but it's much more likely that memory -- say the user asks to load a 500gb file in 50gb of ram -- that memory will never become available in which case what can you do but abort and tell the user to try something else?

What I like to do on error is signal the error and wait to be handled by some other process that can tell the difference between the above policies (by say, interrogating the system or a human operator). And I do mean wait. If the controller tells us to unwind, we unwind to that restart point, which might be as simple as returning an error code. If you're vaguely familiar with how CL's condition system works, this should sound familiar, but it's also what "Abort, Retry, Fail?" used to mean.

> Sometimes handling the error is orthogonal to how a nested call tree is structured. It depends.

On this I agree, but maybe a little bit stronger: I think for errors like this and for domain errors, an ideal error handling strategy is always orthogonal to how the nested call tree is structured (as above). Programming errors are another story -- if you make a lot of programming errors, you almost certainly want what marcus_holmes suggests.

Well there is a language that lets you decide how to handle errors separately from the code that actually handles them (eg separating the code that says “please retry” from the code that retries). That language is Common Lisp. But error handling in it is still a pain.

The one advantage it has over most exception systems in my opinion is that the equivalent of try-finally is much more common than try-catch. With exceptions, code often does weird things because it isn’t expecting to lose control flow when an exception is raised, but most languages don’t make it easy to catch stack unwinding a and clean up. In Common Lisp unwind-protect plus the style of with-foo macros tends to make it more common for functions to work when control transfers out of them in abnormal ways.

Please stop typing. I don't need another reason to learn LISP! I've been successfully stopping myself from entering that rabbit hole for years in order to prevent yet another "hey I should rewrite all my projects in LISP!". Also looking at you, Rust.
Well I was suggesting that the feature doesn’t really turn out that well in CL, so it’s not a great reason. That said, I’ll happily argue that your language should have something like unwind-protect as an easy to use concept before getting something like exceptions.

A big issue with exceptions lately is that they integrate terribly with async style code because they can’t simply unwind past where a promise was created and a promise can be raised to multiple times. The other issue is that they are so pervasively nonlocal that typical code can’t know what might be raised (or what restarts might be available)

I agree. I've been implementing the Event Stream architecture recently in Go and dealing within unwinding errors is a pain in the arse. Do I just repeat the event and hope it works next time? Is there something wrong with the event that means it'll never work? Do I restart the database server because that's what's wrong?

There's this saying that the AA has: "wherever you go, there you are". It's about "doing a geographic" - thinking that moving city/country/continent will change your circumstances and therefore change you. It's false, because no matter where we move we're still the same person so we'll still face the same problems. Wherever we go, there we are.

I think programmers have the same dynamic - if I change my language, I won't make the same errors as I'm making here. Somehow this new language will make me a better programmer because x or y.

I find this difficult to resist. But I also realise its falsehood - I will not be a better programmer in Rust or Lisp than I am in Go. In fact, I have a much better chance of being a better programmer if I drill down in Go and unlearn some problems and relearn some patterns and generally stop learning syntax and start learning deep shit.

Go has a convention on error handling. It may not be ideal. But it's there for a reason, and while we can argue with the reason, it's a valid reason. As a Go programmer, I can fight it and basically reject the language, or I can adopt it and get deeper. I choose that.

But that doesn't mean I don't dream of how much better my life would be if I chose Rust or LISP instead. And yes, I know that all languages have their problems, and a year after learning LISP I wouldn't be writing some infuriated blog post on how EMACS does this weird shit that takes 30s to resolve on a remote server. But don't we all dream of that promised land?

The problem with 'pokemon' exception handling (you have a 'gotta catchem all' exception handler) is that when someone inadvertantly puts a exception handler somewhere in the middle of the call stack it creates hard to find bugs. I've actually seen this in practice and it's a pain to debug.
Sure but the advantage of explicit returns is that you can easily see where the error is returned and also add context to it. The disadvantage is a little more repeated code, which isn’t IMO a huge burden.

With exceptions it is harder to know where or if it might be handled.

How is it easier to know where or if it might be handled?

If you return a error, you still don't know nothing, but that a caller in the chain to the bottom might handle it. There is no difference compared to exceptions, except you know that every caller will have to deal with boiler plate no matter if he is interested.

Errors must be passed manually up a call stack if they are not handled, so in practice this encourages handling them as soon as possible. That makes it easier to find where the error is handled.

Exceptions are automatically passed up, so in practice are often caught by one catcher at the top level which is not very useful and has no idea what to do with the error.

It's a very different mechanism. There is certainly more boilerplate with the Go approach.