loading...

Go 2 Draft: Error Handling

dean profile image Dean Bassett ・4 min read

Go 2 is being drafted.

If you haven't heard, there will be a few major language changes coming to Go 2. The Go team has recently submitted a draft of these changes, and as part of a 3-part series, I want to go through each part, explain it, and state my opinion on them! This is the first part of the series. After Error Handling, we will talk about Error Values, and then we can get to the big kahuna... Generics!

Remember that these are just drafts. They will most likely be added to the language, but there is still a chance that it won't. Also, remember that these drafts are not final, of course. The syntax will probably be a bit different from what it is right now.

These may not also be the only changes to the language, but these will probably be the only major changes.

So, what's the big deal with Error Handling?

Currently, if you want to check an error, you need to do if err != nil { .... The construct is nice, but when you're working with any kind of I/O, your code starts to look something like this... (using the example from the draft)

// From the draft
func CopyFile(src, dst string) error {
    r, err := os.Open(src)
    if err != nil {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
    defer r.Close()

    w, err := os.Create(dst)
    if err != nil {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }

    if _, err := io.Copy(w, r); err != nil {
        w.Close()
        os.Remove(dst)
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }

    if err := w.Close(); err != nil {
        os.Remove(dst)
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
}

We had to write fmt.Errorf("copy %s %s: %v", src, dst, err) three times, and w.Close() and os.Remove(dst) twice... and this is a pretty small function. So how can we fix this?

Introducing: check and handle!

check and handle will be new keywords, and you can kind-of think of them as a panic-defer-recover, but for use within a single function.

The format for check is check <expression>, where <expression> is either an error or a function call, where the last return value is an error. These are also expressions themselves, if the error is nil, then it returns all of the other arguments (ex: f := check os.Open(fileName)).

If the error value is not nil, then check will jump to the handle blocks. To see what I mean, let's look at a simple example.

Go 1:

type Parsed struct { ... }

func ParseJson(name string) (Parsed, error) {

    // Open the file
    f, err := os.Open(name)
    if err != nil {
        return fmt.Errorf("parsing json: %s %v", name, err)
    }
    defer f.Close()

    // Parse json into p
    var p Parsed
    err = json.NewDecoder(f).Decode(&p)
    if err != nil {
        return fmt.Errorf("parsing json: %s %v", name, err)
    }

    return p
}

Okay, so let's simplify this with check and handle!

type Parsed struct { ... }

func ParseJson(name string) (Parsed, error) {
    handle err {
        return fmt.Errorf("parsing json: %s %v", name, err)
    }

    // Open the file
    f := check os.Open(name)
    defer f.Close()

    // Parse json into p
    var p Parsed
    check json.NewDecoder(f).Decode(&p)

    return p
}

How about that? We've trimmed down a lot of our boilerplate!

But what about that example from earlier... It had a lot more error handling! We had to clean up our files if they didn't copy properly... So how do we handle that? Let's take a look at the draft!

// From the draft
func CopyFile(src, dst string) error {
    handle err {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }

    r := check os.Open(src)
    defer r.Close()

    w := check os.Create(dst)
    handle err {
        w.Close()
        os.Remove(dst) // (only if a check fails)
    }

    check io.Copy(w, r)
    check w.Close()
    return nil
}

If you have multiple handle blocks, they will be executed from the bottom-most one to the top-most. This allows you to have more clean-up when an error occurs as a function flows.

Of course, this construct also works in functions which do not return errors, as noted in the draft!

// From the draft
func main() {
    handle err {
        log.Fatal(err)
    }

    hex := check ioutil.ReadAll(os.Stdin)
    data := check parseHexdump(string(hex))
    os.Stdout.Write(data)
}

What do I think?

I think this is a really good change! My only real issue with it is that now check and handle become keywords, which are both variable names that aren't uncommon. I used to like the idea of collect (as illustrated in my previous blog post), although I think my view on collect changed after I wrote it. It just seemed like try-catch and didn't have a very intuitive syntax.

check-handle is still quite a bit like try-catch, although it is much more powerful. It allows more clean-up as the function progresses without adding more indentation. Every error check is also explicit, so you know where the exit points of the function are at a very quick glance.

The draft also contains a lot of details about what inspired this decision! They took a lot of inspiration from both Rust and Swift, which both have errors that must be checked explicitly (unlike Java, C++, Python, etc. which have implicit unwrapping of the stack).

This change also works really well with the new error idioms... but that's for the next post in the series!

Discussion

pic
Editor guide
Collapse
theodesp profile image
Theofanis Despoudis

Eventually you do something like this:

package main

import (
    "log"
    "io/ioutil"
    "os"
)

func main() {
    handle := func (v interface{}, err error) interface{} {
        if err != nil {
            log.Fatal(err)
        }
        return v
    }

    hex := handle(ioutil.ReadAll(os.Stdin))
    data := handle(parseHexdump(string(hex)))
    os.Stdout.Write(data)
}

Which I've seen in some other examples as:

package main

import (
    "log"
    "io/ioutil"
    "os"
)

func main() {
    checkError := func (err error) {
        if err != nil {
            log.Fatal(err)
        }
    }

    hex, err := ioutil.ReadAll(os.Stdin)
    checkError(err)
    data, err = parseHexdump(string(hex))
    checkError(err)
    os.Stdout.Write(data)
}

To be honest, while this proposed solution works, it still looks like we only saving a few keystrokes. It's good but not great.

Collapse
alsotang profile image
alsotang

It's totally different. By your way, you can stop the subsequent operations when an error occurs

Collapse
dean profile image
Dean Bassett Author

That doesn't work for returning errors, though. What if I wanted to return a wrapped version of the error?

Collapse
theodesp profile image
Theofanis Despoudis

Fair enough, but in that case, the wrapping is implicit and it looks like a twisted version of a try/catch block, something that spreads confusion.

I would prefer something like this:

func main() {
    check {
        hex := ioutil.ReadAll(os.Stdin)
        data := parseHexdump(string(hex))
        os.Stdout.Write(data)
    } handle err {
        switch err.(type) {
    case *os.ErrNotExist:
        // Code...
    case ...:
        // ...
    default:
        // ...
    }
}

As its more structured, easier to read and less confusing than the draft proposal. Note that the handle could be triggered multiple times just as the original handle version.

Thread Thread
dean profile image
Dean Bassett Author

But now there's very implicit error checking, you don't know which lines may result in an error, which is something the proposal was trying to avoid.

Collapse
jbristow profile image
Jon Bristow

tldr: go maintainers stubbornly refuse to admit that nil and not having union types are their two biggest mistakes.

Mistakes that are unfixable at this point.

Collapse
dean profile image
Dean Bassett Author

From the Go 2017 user survey:
2017 user survey

Union/Sum types are never mentioned (although "types" is)... Honestly I think I can live fine without sum types

Error handling, dependency management, and generics are by far the most mentioned things, so those are what they are focusing on.

Collapse
sirkon profile image
Denis

Honestly I think I can live fine without sum types

Honestly I think you have never used them before, aren't you?

Thread Thread
dean profile image
Dean Bassett Author

Uhhh nope, can't say I've ever had the desire to use them though

I know some people really adore them, and I know they can be useful, but in Go we've got interfaces and maybe even contract-based generics soon, which honestly kinda makes it so we don't really have a large need for union types 🤷‍♂️

Thread Thread
sirkon profile image
Denis

Uhhh nope

You doesn't talk like a coder. Real coder will answer "Nope, I used them" or "Indeed, I didn't use them". I don't understand you.

but in Go we've got interfaces

Are you sure about "uhhh nope"? Visitor pattern destroys readability (and performance for short unions replacement - and we actually need only short unions), other interface based approaches in Go don't provide type safety.

even contract-based generics soon

generics are orthogonal to to algebraic/variadic data types. They become much better with generics though and can provide full type safety for error handling (unlike current approach).

I am afraid you are actually have a little sense about the idea.

Thread Thread
jbristow profile image
Jon Bristow

Don't waste your time arguing. Go is designed to minimize amount of computer science needed to do work. It is NOT designed for productivity or engineer happiness. It is specifically designed to make developers easily replaceable.

Collapse
m_kunc profile image
Martin Kunc

Hi Dean,
I feel like adding handle fallbacks is limiting my chance to spot which place exactly generated the error.
Originally we could generate different errors for various reason. The example above is actually using same messages all the time. I know we have the line number, but I still preferred the "copyfile os.open error %s" format to specify exact spot individually.
I am not saying this won't allow it, it will just look very similar.

I would love it we would have also name of function passed to handler, ie:

handle err, fn {
  log.Fatal(fmt.Errorf("%s %s", fn, err))
}

hex := check ioutil.ReadAll(os.Stdin)
data := check parseHexdump(string(hex))

What do you think ?

Collapse
dean profile image
Dean Bassett Author

I actually quite like this idea! I'd suggest heading over to the wiki and put it there!

Collapse
rhymes profile image
rhymes

Hi Dean,

I'm a little conflicted on this.

First: instead of having the pattern var, err := method() now the code will be "littered" with var := check method(). I hope they can find a way to make that check implicit. I'm still not sure how I feel about those handle blocks, I've only skimmed the draft yesterday.

Second: I like this reaction about the whole thing:

Third: about handle blocks: think about reading the code after you wrote it, maybe weeks after. You read the code, and instead of seeing how the error is handled right there you need to jump up (hopefully not many levels) to see the various conditions, then you can resume reading the code, then you see another check so you go back up with your eyes to see what happens again with the error, then you resume, then you go back up again...

ps. you can add syntax highlighting to the code blocks, like:

// From the draft
func main() {
    handle err {
        log.Fatal(err)
    }

    hex := check ioutil.ReadAll(os.Stdin)
    data := check parseHexdump(string(hex))
    os.Stdout.Write(data)
}

this way:

go with syntax highlighting

Collapse
dean profile image
Dean Bassett Author

Implicit error checking is just kinda... bad. As a quote from the draft -

The subtleties of implicit checking are covered well in Raymond Chen’s pair of blog posts, “Cleaner, more elegant, and wrong” (2004), and “Cleaner, more elegant, and harder to recognize” (2005). In essence, because you can’t see implicit checks at all, it is very hard to verify by inspection that the error handling code correctly recovers from the state of the program at the time the check fails.

In terms of that reaction, I actually like that. Once a handle leaves scope, it should no longer be used.
I also think that defer should work the same way. A defer should execute when it leaves its scope, not when the function is done executing. It just makes more sense in my eyes and is much more intuitive.

For the syntax highlighting, I used a capital "G" on accident, I'll fix that!

Collapse
rhymes profile image
rhymes

Implicit error checking is just kinda... bad. As a quote from the draft

I meant, implicit usage of check (you can derive which functions need a check if one of the return values is error) but still with explicit error handling.

But now, while writing this, I think I understood. If they remove the explicit check there's no way for the programmer to instantly figure out which method calls can trigger an error, unless they inspect the source code.

Using check also allows the compiler to throw errors if someone forgot to add check.

Collapse
stealthmusic profile image
Jan Wedel

Although I’m not a go developer I love to check out new languages and concepts they bring. I never really liked the cluttering aspect of error handling in go but this check handle pattern is really confusing.
In the examples from another comment, there were multiple handle blocks. Which one is going to be used? How can I handle two different errors in the same scope differently. Why is the handle block before the check block?

Collapse
dean profile image
Dean Bassett Author

To answer your questions -

  1. The handle blocks chain. Once an handle block is reached, it becomes part of the chain. If an error occurs, the blocks execute from the bottommost block to the topmost. (It is proposed to remove the chaining functionality, though)

  2. If you simply want to add functionality, just add another handle block when you want that functionality to start being used. (Like the file-copy example I have in the post). Otherwise you'll have to do the standard if err != nil

  3. There's a few reasons behind this:

a. It's easier to think about needing to "declare" your handle blocks. You can't use a handle block if it hasn't been declared yet!

b. It makes for much faster compile times to require handle blocks.

c. It makes it more similar to how defer works (if you don't know Go this doesn't mean much)

d. Enabling a handle block to be after a check block means that the handle block may have more variables in it's scope than what was at the check block...


// Demonstrates why `handle` must be above `check`
func ErrorTesting() {
    check errors.New("some kind of error") // will go to handle block

    i := 5
    handle err {
        log.Fatalf("i=%d, err=%v", i, err)
        // Since we got here from the top line, i has not been declared!
    }
}
Collapse
stealthmusic profile image
Jan Wedel

Thanks for your elaborate reply.

Maybe I shouldn’t do this, but in my head I compare this to the try/catch/finally concept. Catch comes after try but cannot use any variables that have not been declared before try. But it makes the flow much clearer since you usually read code from top to bottom.
And one of my questions was how to implement multiple catch blocks. With try/catch i can have muktiple of those constructs or even multiple catch blocks. Let’s say I have file operation and a parsing operation and I want to handle them differently, I could declare two different catch blocks. How would I do this with check/handle?

Thread Thread
dean profile image
Dean Bassett Author

It would look something like this:

In current Go with the handle construct would look something like this:

handle err {

    if notExist, ok := err.(*os.ErrNotExist); ok {
        // Do stuff with notExist
    } else if otherErr, ok := err.(*pack.OtherErr); ok {
        // Do stuff with otherErr
    }
}

// Or if your list is very long
handle err {
    switch err.(type) {
    case *os.ErrNotExist:
        // Code...
    case ...:
        // ...
    default:
        // ...
    }
}

Note that the notExist, ok := err.(*os.ErrNotExist) is how you typecast in Go. Also, a semicolon in an if statement means "just execute the left side normally and evaluate the right side as the condition", although any variables declared on the left side have block scope within the if statement.

Errors in Go are just an interface, so you just cast them to the error that you want to check.

Collapse
jitheshkt profile image
Jithesh. KT

When go2 will be released?

Collapse
dean profile image
Dean Bassett Author

They've said Go 2 will probably be around Go 1.15 or so. So there's still a long time, Go 2 is still in very early planning phase, and they take their time.