Error handling in Go 2

    title


    Just a couple of days ago in Denver, the next, already the 5th Go, the largest Go conference - GopherCon, ended . On it, the Go team made an important announcement - the drafts of the preliminary design of error handling and generics in Go 2 are published , and everyone is invited to discuss.


    I will try to retell in detail the essence of these drafts in three articles.


    As many probably know, last year (also at GopherCon), the Go team announced that it was collecting experience reports and suggestions for solving the main problems of Go — those moments that polls collected the most criticism. During the year, all offers and reports were studied and reviewed, and helped in the creation of design drafts, which will be discussed.


    So let's start with the drafts of the new error handling mechanism .


    For a start, a small digression:


    1. Go 2 is a conditional name - all innovations will be part of the usual Go version release process. So it is not yet known whether this will be Go 1.34 or Go2. Python script 2/3 will not be iron.
    2. Design drafts are not even proposals (proposals) , with which any change in the library, tooling or Go language begins. This is the starting point for a design discussion suggested by the Go team after several years of working on these issues. Everything that is described in the drafts is very likely to be changed, and, with the best hands, it will become a reality in only a few releases (I give ~ 2 years).

    What is the problem with error handling in Go?


    In Go, it was originally decided to use "explicit" error checking, as opposed to the "implicit" checking popular in other languages ​​- exceptions. The problem with implicit error checking is how it is described in detail in the article “Cleaner, more elegant and not more correct” , that it is very difficult to visually understand whether the program behaves correctly in case of certain errors.


    Take the example of a hypothetical Go with exceptions:


    funcCopyFile(src, dst string)throwserror {
        r := os.Open(src)
        defer r.Close()
        w := os.Create(dst)
        io.Copy(w, r)
        w.Close()
    }

    This is a nice, clean and elegant code. It is also incorrect: if io.Copyor w.Closefail, this code will not delete the created and underwritten file.


    On the other hand, the code on real Go looks like this:


    funcCopyFile(src, dst string)error {
        r, err := os.Open(src)
        if err != nil {
            return err
        }
        defer r.Close()
        w, err := os.Create(dst)
        if err != nil {
            return err
        }
        defer w.Close()
        if _, err := io.Copy(w, r); err != nil {
            return err
        }
        if err := w.Close(); err != nil {
            return err
        }
    }

    This code is not so pleasant and elegant, and, at the same time, it is also incorrect - it still does not delete the file in case of the errors described above. It is fair to say that explicit processing pushes a programmer reading the code to ask the question “what is right to do with this error”, but because checking the code takes a lot of space, programmers often learn to skip it to better understand the structure of the code. .


    Also in this code, the problem is that it is much easier to forward an error without additional information (lines and file where it occurred, the name of the file being opened, etc.) to the top than to correctly enter the details of the error before passing to the top.


    Simply put, Go has too much error checking and not enough error handling. A more complete version of the code above will look like this:


    funcCopyFile(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)
        }
    }

    Correction of problems made the code correct, but not cleaner or more elegant.


    Goals


    The Go team sets the following goals to improve error handling in Go 2:


    • make error checking easier by reducing the amount of text in the program responsible for checking the code
    • make error handling easier by increasing the likelihood that programmers will do it
    • and checking and error handling should remain explicit - that is, easily visible when reading the code, without repeating the problems of exceptions
    • existing Go code must continue to work, any changes must be compatible with the existing error handling mechanism

    A draft of the design suggests changing or supplementing the semantics of error handling in Go.


    Design


    The proposed design introduces two new syntactic forms.


    • check(x,y,z)or check errdenoting an explicit error check
    • handle - defining error handling code

    If it checkreturns an error, then the control is transferred to the nearest block handle(which transfers the control to the next one in the lexical context handler, if there is one, and then it calls return)


    The code above will look like this:


    funcCopyFile(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) // (только если check упадёт)
        }
        check io.Copy(w, r)
        check w.Close()
        returnnil
    }

    This syntax is also allowed in functions that do not return errors (for example main). Next program:


    funcmain() {
        hex, err := ioutil.ReadAll(os.Stdin)
        if err != nil {
            log.Fatal(err)
        }
        data, err := parseHexdump(string(hex))
        if err != nil {
            log.Fatal(err)
        }
        os.Stdout.Write(data)
    }

    can be rewritten as:


    funcmain() {
        handle err {
            log.Fatal(err)
        }
        hex := check ioutil.ReadAll(os.Stdin)
        data := check parseHexdump(string(hex))
        os.Stdout.Write(data)
    }

    Here is another example to get a better idea. Original code:


    funcprintSum(a, b string)error {
        x, err := strconv.Atoi(a)
        if err != nil {
            return err
        }
        y, err := strconv.Atoi(b)
        if err != nil {
            return err
        }
        fmt.Println("result:", x + y)
        returnnil
    }

    can be rewritten as:


    funcprintSum(a, b string)error {
        handle err { return err }
        x := check strconv.Atoi(a)
        y := check strconv.Atoi(b)
        fmt.Println("result:", x + y)
        returnnil
    }

    or even like this:


    funcprintSum(a, b string)error {
        handle err { return err }
        fmt.Println("result:", check strconv.Atoi(x) + check strconv.Atoi(y))
        returnnil
    }

    Let's look at the details of the proposed designs checkand handle.


    Check


    checkit is (most likely) a keyword that clearly expresses the action "check" and is applied either to a type variable erroror to a function that returns an error with the last value. If the error is not nil, then it checkcalls the closest handler ( handler), and calls the returnhandler with the result.


    The following example:


    v1, ..., vN := check <выражение>

    equivalent to this code:


    v1, ..., vN, vErr := <выражение>
    if vErr != nil {
        <error result> = handlerChain(vn)
        return
    }

    where vErrshould be of type errorand <error result>means error returned from handler.


    Similarly


    foo(check <выражение>)

    is equivalent to:


    v1, ..., vN, vErr := <выражение>
    if vErr != nil {
        <error result> = handlerChain(vn)
        return
    }
    foo(v1, ..., vN)

    Check vs try


    Initially tried the word tryinstead check- it is more popular / familiar, and, for example, Rust and Swift use try(although Rust goes in favor of postfix ?already).


    try well read with functions:


    data := try parseHexdump(string(hex))

    but it was completely unreadable with the meaning of errors:


    data, err := parseHexdump(string(hex))
    if err == ErrBadHex {
        ... special handling ...
    }
    try err

    In addition, tryit still carries the baggage of similarity with the mechanism of exceptions and can be misleading. Since the proposed design check/ is handlesignificantly different from exceptions, the choice of an explicit and eloquent word checkseems optimal.


    Handle


    handledescribes a block called a "handler" (handler) that will handle the error passed to check. A return from this block means an immediate exit from the function with the current values ​​of the returned variables. Returning without variables (that is, simply return) is possible only in functions with named return variables (for example func foo() (bar int, err error)).


    Since there can be several handlers, the concept of a "chain of handlers" is formally introduced - each of them is, in essence, a function that accepts a type variable errorand returns the same variables as the function for which the handler is defined. But the handler semantics can be described like this:


    funchandler(err error)error {...}

    (This is not how it is actually likely to be implemented, but for ease of understanding, you can still consider it as such - each next handler receives the result of the previous input).


    Handler order


    The important point is to understand in what order the handlers will be called if there are several of them. Each check ( check) may have different handlers, depending on the osprey in which they are called. The first will be the handler that is most closely declared in the current osprey, the second is the next in the reverse order of the declaration. Here is an example for better understanding:


    funcprocess(user string, files chanstring)(n int, err error) {
        handle err { return0, fmt.Errorf("process: %v", err)  }      // handler Afor i := 0; i < 3; i++ {
            handle err { err = fmt.Errorf("attempt %d: %v", i, err) } // handler B
            handle err { err = moreWrapping(err) }                    // handler C
            check do(something())  // check 1: handler chain C, B, A
        }
        check do(somethingElse())  // check 2: handler chain A
    }

    The check check 1will call C, B, and A handlers in that order. The check check 2will only call A, since C and B were defined only for the for-loop Osprey.


    Of course, this design preserves the original approach to errors as to ordinary values. You are also free to use the usual iferror checker, and in the error handler ( handle) you can (and should) do what best suits the situation - for example, to complement the error with details before processing it in another handler:


    type Error struct {
        Func string
        User string
        Path string
        Err  error
    }
    func(e *Error)Error()stringfuncProcessFiles(user string, files chanstring)error {
        e := Error{ Func: "ProcessFile", User: user}
        handle err { e.Err = err; return &e } // handler A
        u := check OpenUserInfo(user)         // check 1defer u.Close()
        for file := range files {
            handle err { e.Path = file }       // handler B
            check process(check os.Open(file)) // check 2
        }
        ...
    }

    It is worth noting that it is handlesomewhat reminiscent defer, and you can decide that the order of the call will be the same, but it is not. This difference is one of the weak points of this design, by the way. In addition, it handler Bwill be executed only once - a similar call deferin the same place would lead to multiple calls. Go team tried to find a way to unify the defer/ panicand handle/ checkmechanisms, but did not find a reasonable option that would not make the language back-incompatible.


    Another important point is that at least one handler must return values ​​(that is, call return) if the original function returns something. Otherwise it will be a compilation error.


    Panic is executed in the handlers in the same way as in the function body.


    Default handler


    Another compilation error is if the handler code is empty ( handle err {}). Instead, the concept of "default handler" is introduced. If you do not define any handleblock, then by default, the same error will be returned that the checkother variables received unchanged (in named return values; in unnamed ones, zero values ​​will be returned).


    Sample code with default handler:


    funcprintSum(a, b string)error {
        x := check strconv.Atoi(a)
        y := check strconv.Atoi(b)
        fmt.Println("result:", x + y)
        returnnil
    }

    Saving call stack


    For valid stackrays, Go treats handlers as code that is called from a function in its own stack. You will need some kind of mechanism that allows you to ignore the handler code in the framerays, for example, for tabular tests. Most likely, the use t.Helper()will be enough, but this is still an open question:


    funcTestFoo(t *testing.T) {
        handle err {
            t.Helper()
            t.Fatal(err)
        }
        for _, tc := range testCases {
            x := check Foo(tc.a)
            y := check Foo(tc.b)
            if x != y {
                t.Errorf("Foo(%v) != Foo(%v)", tc.a, tc.b)
            }
        }
    }

    Shadowing variables


    Usage checkcan practically eliminate the need to redefine variables in a short form of assignment ( :=), since this was dictated by the need to reuse err. With the new mechanism handle/ checkshading variables may generally become irrelevant.


    Open questions


    defer / panic


    Using similar concepts ( defer/ panicand handle/ check) increases the cognitive load on the programmer and the complexity of the language. Not very obvious differences between them open the door to a new class of errors and the misuse of both mechanisms.


    Since handleit is always called before defer(and, I remind you, the panic in the code of the handler is processed in the same way as in the normal body of the function), there is no way to use handle/ checkin the body of the defer. This code will not compile:


    funcGreet(w io.WriteCloser)error {
        deferfunc() {
            check w.Close()
        }()
        fmt.Fprintf(w, "hello, world\n")
        returnnil
    }

    It is not clear how to solve this situation beautifully.


    Reducing code locality


    One of the main advantages of the current error handling mechanism in Go is high locality - the error handler code is very close to the error getting code, and executed in the same context. The new mechanism introduces a context-sensitive "leap", similar to exceptions at the same time, to defer, to, breakand to goto. And although this approach is very different from exceptions, and more like gotoit, it is still one concept that programmers will need to learn and keep in mind.


    Keyword Names


    It is considering the use of words such as try, catch, ?and other, potentially more friends from other languages. After experimenting with all the authors Go believe that check, and handleit is best to fit into the concept and reduce the likelihood of misinterpretation.


    What to do with the code, in which the names handleand catchhave already been defined, while also not clear (not the fact that it will be the keywords (keywords) more).


    Frequently asked Questions


    When will Go2 come out?


    Unknown. Considering the past experience of innovations in Go, there are 2-3 releases from the discussion stage to the first experimental use, and the official introduction goes through the release. If you start from this, then it is 2-3 years with the best deal.


    Plus, not the fact that it will be Go2 - this is a question of branding. Most likely, there will be a regular release of the next version of Go - Go 1.20, for example. Nobody knows.


    Isn't it the same as exceptions?


    Not. In exceptions, the main problem is the implicit / invisible code and error handling process. This design lacks such a flaw, and is, in fact, syntactic sugar for routine error checking in Go.


    Doesn't this divide the Go programmers into 2 camps - those who will remain faithful to the if err != nil {}checks, and supporters of handle/ check?


    It is not known, but the expectation is that it if errwill make little sense to use, except in special cases - the new design reduces the number of characters for typing, and keeps the validity of checking and error handling. But time will tell.


    Is not a step to the complication of the language? Now there are two ways to do the processing and error checking, and Go avoids this.


    Is an. The expectation that the benefit of this complication will outweigh the disadvantages of the very fact of complication.


    Is this the final design and exactly will it be adopted?


    No, this is just the initial design, and it may not be accepted. Although there are reasons to believe that at some point. after active testing, it will be adopted, perhaps with strong changes, correcting weaknesses.


    I know how to make the design better! What should I do?


    Write an article explaining your vision and add it to the Go2ErrorHandlingFeedback wiki page


    Summary


    • Proposed a new error handling mechanism in future versions of Go -   handle/check
    • Backward compatible with current
    • Error checking and handling remain explicit.
    • The amount of text is reduced, especially in pieces of code, where many repetitions of the same type errors
    • Two new elements are added to the grammar of the language.
    • There are open / unresolved issues (interaction with defer/ panic)

    Links



    Thoughts? Comments?



    Also popular now: