Settings

Theme

Errors vs. exceptions in Go and C++ in 2020

dr-knz.net

63 points by yuribro 5 years ago · 72 comments

Reader

marcus_holmes 5 years ago

I think they missed the point of Go's convention. It's designed to force people to handle the damn error as near to the call as possible.

I've seen way too many programs with a single exception handler right at the base of the program, that just goes "whoops, something bad happened, bye!". I've even seen this anti-pattern used with Go's panic-recover mechanism.

It's an interesting find though, that the actual performance cost for checking the error return is random, variable, and small. Good to know :)

  • jasode 5 years 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.

    • Cthulhu_ 5 years ago

      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.

      • onionisafruit 5 years ago

        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.

      • marcus_holmes 5 years ago

        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 :)

    • marcosdumay 5 years ago

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

    • ragnese 5 years ago

      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.

      • klodolph 5 years ago

        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.

        • ragnese 5 years ago

          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.

          • klodolph 5 years ago

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

        • KineticLensman 5 years ago

          > 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

      • corty 5 years ago

        My irony detector is buzzing. You just described err vs. panic in go.

      • friendzis 5 years ago

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

        • ragnese 5 years ago

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

    • geocar 5 years ago

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

    • dan-robertson 5 years ago

      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.

      • marcus_holmes 5 years ago

        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.

        • dan-robertson 5 years ago

          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)

          • marcus_holmes 5 years ago

            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?

    • ascotan 5 years ago

      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.

    • grey-area 5 years ago

      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.

      • kitkat_new 5 years ago

        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.

        • grey-area 5 years ago

          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.

  • grandinj 5 years ago

    > too many programs with a single exception handler right

    For a number of useful applications, this is exactly the right, correct, and most useful approach.

    I currently maintain several successful (within our commercial niche) 100kLOC+ programs that largely use such an architecture.

    It puts the error-handling code in one place, and enables common logging, recovery, filtering and display.

    It means that the vast majority of the code can happily just assume that the world is full of unicorns and light.

    And given that it is written in Java, the program just largely keeps on running, even in the presence of bugs and weird edge cases, and suchlike, a feature our users really like.

    Human are pretty good at going "OK, so that part of the program is having a bad day, I'll report the bug and keep on using the rest of the program".

  • masklinn 5 years ago

    > It's designed to force people to handle the damn error as near to the call as possible.

    Except for not even remotely doing that:

    1. if a call can fail but returns no useful value (or the caller cares little about it, and thus ignores everything it returns), Go will not complain that you're ignoring the return value entirely

    2. if you have several calls which can fail, nothing forces you to actually handle all the errors, because Go doesn't check for that, it relies on the compiler error that a variable must be used:

        v1, err := Foo(false)
        if err != nil {
            fmt.Println("error")
            return
        }
        fmt.Println("first", v1)
        v2, err := Foo(true)
        fmt.Println("second", v2)
    
    will not trigger any error, because the second calls simply reassigns to the existing `err`, which has already been used once, and thus is fine by the compiler.
    • marcus_holmes 5 years ago

      Yeah it's a convention. It's not enforced by the compiler. It is caught by several of the static code checking tools (and some linters I believe). You can ignore the convention if you want (you probably shouldn't, but you can).

      You could make the case that this is a footgun, sure. I prefer to think of it as giving me the right tools to make the right choice in my specific circumstances.

  • coldtea 5 years ago

    >It's designed to force people to handle the damn error as near to the call as possible.

    If they wanted to "force people" they could use Optionals and really force them.

    This no more forcing than mandating checked exceptions -- the user can just return the err immediately, like in Java they can just add a throws and propagate for others to handle, or an empty try/catch and ignore it...

    • dgellow 5 years ago

      You have go-lint or other linters to enforce it. It’s a per-project choice.

      • coldtea 5 years ago

        It's either a "per-project/leave it to the linter" choice, or a "they did this in the language to force people to handle errors close to the source".

        Can't have it both ways!

        • dgellow 5 years ago

          The “force” is the part that is incorrect, or at least misleading. The go approach is to encourage people to handle it at the call site, and you can use extra tooling to enforce it.

          Once you get that point, there is no contradiction.

    • Someone 5 years ago

      IMO, Optionals or Either are superior to returning a pair (value, error) because they cannot return both a value and an error, thus removing one possible cause of bugs (likely a fairly small one! As it doesn’t prevent a function from constructing both a result and an error and only returning the error), but I don’t see how optionals force handling errors more than returning a pair (value, error).

      Surely, you can just check whether the optional has a value, use it when it is available, and ignore the other case.

  • knz42 5 years ago

    > that the actual performance cost for checking the error return is random, variable, and small. Good to know :)

    That is certainly not the article's conclusion. The cost is deterministic, constant and non-negligible.

    • marcus_holmes 5 years ago

      > Previously, in Go 1.10, this fixed cost was non-negligible, climbing upwards of dozens of nanoseconds. Thanks to recent improvements in the Go compiler however, as well as general improvements in CPU micro-architectures, this cost has been greatly reduced in 2020.

      I read that as "used to be non-negligable, is now negligable"

      4%-10% depending on compiler and architecture is pretty variable, to my way of thinking. YMMV.

      also kinda random, in that there's nothing I can do in the code to determine how much overhead it costs, or change that (apart from ignoring Go's convention on error handling completely, which I'm not going to do because it wasn't a convention for performance reasons in the first place).

      • knz42 5 years ago

        You're mis-reading the text.

        The reduction in cost pertains to the try/catch (defer/recover) mechanism, not error returns.

        The cost of error returns has not reduced since Go 1.10.

    • Rochus 5 years ago

      > and non-negligible

      This is probably a matter of discretion. Considering the overall performance of Go applications compared to other languages, 4 to 10% is quite low. The measurement error might also be a few percent.

    • Bootvis 5 years ago

      Are you the author? I ask because the domain is similar to your username.

  • otabdeveloper4 5 years ago

    > It's designed to force people to handle the damn error as near to the call as possible.

    This is always the wrong way to handle errors.

    If a function returns an 'error' that needs be handled at the call site, then it isn't an error, it's a variant return type.

    Errors are things that can't be recovered from but must be handled to release resources.

    You want this to happen in some central place, not scattered ad-hoc in every place where you use resources; releasing them by hand is worse than manual memory management.

    • dgellow 5 years ago

      I think there is a misunderstanding. There isn’t just a single type of errors. Every time you get an error object in Go you ask yourself “should I do something about it or not”. If no then you add some context and return it to the parent, that’s a perfectly valid way to handle it. Otherwise you do your specific piece of logic to recreate your ressources or whatever is needed.

      Not all errors require the same treatment and there isn’t a single strategy to manage them.

  • frou_dh 5 years ago

    Go's thing is more "encourage" to handle errors than "force", given that the compiler has nothing to say about unhandled errors in the presence of certain variable reuse patterns, or completely unassigned returns.

    https://play.golang.org/p/mu5fbUrV322

    • dgellow 5 years ago

      That’s the correct way to present it IMHO. With Go you’re encouraged to deal with the error directly (two choices: return it, or do something about it), so that when reading you can follow what is happening at any time. When reading a Go function you can always say for sure if an error occurs with a given call and how it is handled.

      If for some reasons the project consider that checking errors should be enforced, that’s simple to do by using go-lint or other linters.

    • marcus_holmes 5 years ago

      true, good point. And having the power to ignore the convention is good, too.

      • masklinn 5 years ago

        > And having the power to ignore the convention is good, too.

        Mistakenly not handling errors is not "the power to ignore the convention", it's "the language is half assed".

        "The power to ignore conventions" is being allowed but having to explicitly ignore the error, aka that the second and third cases trigger errors, and that you'd have to write:

        y, _ := fmt.Println("Bar") println(y)

        _, _ = fmt.Println("Qux")

        (also note how you can not use `:=` in the second case, because that requires that there be at least one new variable on the LHS)

        • marcus_holmes 5 years ago

          The language allows you to ignore the convention.

          There is a plethora of tools available to allow you to detect if you did that when you didn't mean to. It's just that the compiler doesn't enforce it.

          • pjmlp 5 years ago

            Given that Go also supports binary packages, those tools won't help much there.

  • jcelerier 5 years ago

    > It's designed to force people to handle the damn error as near to the call as possible.

    which is sometimes impossible to do in any meaningful way which just leads people to put panic in there making the end-user experience much worse than having an exception handler at the base of the program / event loop

    • dgellow 5 years ago

      In production code people put panics around? I’ve never seen a situation like this. The convention to not use panic is quite strong

      • knz42 5 years ago

        And yet it certainly is there.

        • andreygrehov 5 years ago

          Panics are rare events that very few functions should ever need to think about. If the library truly cannot set itself up, it might be reasonable to panic (which is why panics are usually in the `main` package only). Once all the invariants have been checked, there is no reason to panic anytime after.

    • akvadrako 5 years ago

      Panic works fine; they are basically just exceptions you can catch at a higher level. I almost exclusively use panic for my exceptions in go.

      • marcus_holmes 5 years ago

        I stopped, because it conflicts so badly with the Go conventions, and because I found myself handling more errors when I didn't use panic.

        I think this is one of those things that new Gophers find hard to adjust to, and older ones realise the wisdom of (there's a few of these in the Go learning journey!).

        I'm not impugning your expertise or implying that you're inexperienced. It's just something I've noticed.

        • akvadrako 5 years ago

          For me it was the other direction. I started using err returns because I knew it was idiomatic, but after doing that for months and seeing no benefit I decided it was just idiotic to continue.

          When I want to handle an error case locally I still use them, but that's extremely rare.

  • gumby 5 years ago

    > I've seen way too many programs with a single exception handler right at the base of the program, that just goes "whoops, something bad happened, bye!".

    Regardless of one's view of execution handling, why would anyone even bother to do this? If you don't catch it and exit the program will exit anyway.

    • dgellow 5 years ago

      To have cleaner logs maybe? Stack trace are often a mess to parse. Or at least correctly close resources such as DB connections before exiting?

  • asdfasgasdgasdg 5 years ago

    The point of Go's convention isn't really relevant to the question of its relative cost compared to exceptions, is it? I don't see that they so much missed it as didn't evaluate it.

  • gpderetta 5 years ago

    actually the best way is not to catch them. Let the application abort and leave a core file you can inspect with full stack trace from the throw point [1] and context.

    [1] I routinely remove "catch and rethrow" from our code base exactly for this reason. There are ways to log and add metadata to in flight exceptions that don't require rethrowing.

enriquto 5 years ago

As a famous software philosopher said (I think it was Uriel): errors are wrong.

Or, to put it more clearly: there are no errors, only conditions that you dislike. It's better to not burden your programming with your emotional shortcomings, and treat all conditions that you may encounter on an equal footing.

You try to open a file; the file may or may not exist, and both cases are equally likely and you get to decide what your program does in each case. No need to attach an emotionally charged label like "error" in one of the two cases of the conditional. Or worse, as some emotional fanatics do, to bend an otherwise clean programming language by adding features (e.g., exceptions) that help support your sentimental disposition.

  • asdfasgasdgasdg 5 years ago

    > both cases are equally likely

    Both cases are not equally likely, though. Also, this article is not about the philosophical approach to naming errors versus exceptions. It's about the performance of two technical approaches to handling exceptional/unlikely circumstances.

    • enriquto 5 years ago

      > Both cases are not equally likely, though.

      Of course, if you call fopen with uniformly distributed random filenames then it is extremely unlikely than such files will exist. Thus it will fail with probability essentially 1. Yet, I don't want my programming language to force me to make an asymmetric distinction between the two cases.

      By "equally likely" I don't mean "having equal probability to occur". This is very difficult to model, and it will depend mostly on the usage patterns of the users of the program. I mean that both cases are worth of the same attention and merit an equivalently serious treatment. No need to disparage one of the two cases as an "error" or an "exception" and require a special language construct.

      • asdfasgasdgasdg 5 years ago

        I assure you, the file-does-not-exist case cares nothing for whether you "disparage" it by calling it an error. As for having a special language construct, the performance benefits discussed in this article are one example of why you might want to have one. If you already have it, using it to represent file not found excursions seems reasonable.

tankenmate 5 years ago

The one item I'd really contend is where it says it "makes it easier to ... maintain over time".

That might be true for smaller code bases (tracking down exceptions generated from libraries called from libraries, fun!), or code bases where you don't use closed external libraries (that can generate unknowable exceptions), or you use only synchronous code (because asynchronous exceptions wind up jumping to fishkill, welcome to distributed systems (logically, physically or chronologically distributed)).

[EDIT] fixed thinko

mseepgood 5 years ago

Are they going to do this again after Go has switched to a register-based calling convention? https://go.googlesource.com/proposal/+/refs/changes/78/24817...

knz42 5 years ago

FYI these results were presented at the Go Systems Conf SF last December: https://www.youtube.com/watch?v=inrqE0Grgk0&t=15126s

peterohler 5 years ago

Great article! As a performance dweeb, any information on how best to squeeze out a bit more performance is welcome. I might have to play with using panics in OjG (https://github.com/ohler55/ojg) and see if it gives a boost.

Keyboard Shortcuts

j
Next item
k
Previous item
o / Enter
Open selected item
?
Show this help
Esc
Close modal / clear selection