golang / go

The Go programming language
https://go.dev
BSD 3-Clause "New" or "Revised" License
123.37k stars 17.58k forks source link

Proposal: "collect": dealing the error handling boilerplate from another angle #32880

Closed ghost closed 5 years ago

ghost commented 5 years ago

Introduction

While there sure is boilerplate about current error handling in Go, there are many things that many people like about it - Go forces programmer to think about and handle errors manually; Go treat errors as value and there is no magic about them (so exit point and flow control are very visible). That makes me think that if err != nil is not boilerplate, instead, they are needed in a lot of cases. So it occurs to me that it is not if err != nil being too much becomes boilerplate, but rather, them being too frequent is the problem.

So, what if we handle error the same way before, but just less frequently? It sounds weird, but we already has that in some idioms. In the Official Go Blog post errors are values, Rob Pike promotes an idiom where errors are collected - but not handled until all other "steps" statements. The steps are simply skipped if there is an error collected before. It seems to me a good solution to many boilerplate caused by too frequently appearing if err != nil. However, it has limited uses since "steps" may not have the same signature and writing a helper wrapper type might as well be boilerplate.

But with help of built-in magic, I think the idea of the idiom can be applied and thus improving much more cases. And I propose so.

Proposal

I propose a new built-in function called collect. The collect built-in takes three arguments: a pointer to an error, a function call, and an error wrapper. If the error held by the pointer is nil, it returns what the function call returns except the last error, which is wrapped by error wrapper and then collected to the error pointer; otherwise, it returns zero values matches the signature of function call. collect cannot be nested - which means that collect appearing in another collect will trigger a compiler error. An error wrapper is a function with signature func(error) error - it takes in an error and returns the wrapped error. If the wrapper is nil, the error is collected without wrapping. If in rare case that the wrapper decides to return nil, nil is collected.

In psedu-code, collect can be written like:

func collect(err *error, f func(...) ..., w func(error) error) ... {
    if *err != nil {
        return _,... // zero values
    }

    v1, v2, ..., _err := f()
    if _err != nil {
        if w != nil {
            _err = w(_err)
        }
        *err = _err
    }
    return v1, v2, ...
}

To avoid bugs from recycling error pointers, consecutive uses of collect should be considered as declaration to the errors, so it can trigger declared and not used error from the compiler.

To reduce boilerplate of writing wrapper helper function, standard library (preferably errors package) should provide factories to wrappers, such as:

func Wrapperf(format string, args ...interface{}) func(error) error {
    return func(err error) error {
        a := append(args,err)
        return fmt.Errorf(format,a...)
    }
}

Example

The example from draft design overview:

func CopyFile(src, dst string) (err error) {
    wf := Wrapperf("copy %s %s: %v", src, dst)
    var err error

        r := collect(&err,os.Open(src),wf)
        defer r.Close()

        w := collect(&err,os.Create(dst),wf)
        if err == nil {
            defer os.Remove(dst)
        }

        collect(&err,io.Copy(w, r),wf)
        collect(&err,w.Close(),wf)
        return err
}

The example of printing sum:

func printSum(a, b string) error {
        var err error
        x := collect(&err,strconv.Atoi(a),Wrapper("a"))
        y := collect(&err,strconv.Atoi(b),Wrapper("b"))
        collect(&err, fmt.Println("result:", x + y), nil)
        return err
}

As for the example using nested try, collect shall not be nested.

The main function from rsc's unhex program can be simplified without splitting:

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

Since collect does not handle errors, language tools to deal with errors can still work well:

n := collect(&err,src.Read(buf),nil)
if err == io.EOF {
        err = nil
        break
}
collect(&err,otherStuff(),nil)

Pros and Cons

Discussion

networkimprov commented 5 years ago

See also #25626

ghost commented 5 years ago

Though sharing name with #25626, I would argue they are very different. This proposal does not include anything like goto, and does not require collected errors being in the same block.

beoran commented 5 years ago

This is a good use case for hygienic macros: #32620

ianlancetaylor commented 5 years ago

This is an interesting proposal, but I note that it doesn't support what is by far the most common case when an error occurs: returning from the function. It's true that if your function is all top-level calls to collect then this doesn't matter, since collect will skip execution of everything after an error occurs. But most functions have conditionals and loops, and using those with collect will require lots of function literals, which is simply too awkward and is in fact almost like a continuation passing language. To me those seem like fatal problems with this idea.

ghost commented 5 years ago

@ianlancetaylor Yes, it does not support returing from the function, and I deliberately propose so, since the biggest issue all error handling proposal I've seen so far has a common problem of obscuring control flow and exit points.

I am aware of collect, being a builtin and a non-returner, would not cope well with things other than basic expressions; it is not designed to do so. I justify this by saying: collect is not meant to handle error, rather, it only collects them. If there is a loop or an conditions, it would be a case that the code requires better and more dedicated error handling - by that, if err != nil and other language stuff comes to play. I would say this is a good side effects - it forces programmer to think about errors when entering more complex code blocks. And on the other hand, I would argue that error handling before loops or conditions are unlikely boilerplate code to begin with.

However, like opionions I presented on the proposal, all these are based on experience of my own and those who are close to me, which by no means can match up the Go society; but I do not have ability to get more datas and opionions on them, so I am unclear about whether they are widely accepted or not.

mvndaai commented 5 years ago

Since collect returns the zeros of the function calls but does return from the function wouldn't this mean that if there is an error the flow would continue but with zeros?

Meaning if os.Open returned an error, then r would be a zero. Meaning the defer r.Close() would attempt to close a zero, maybe nil. Then io.Copy would try to copy from a zero?

r := collect(&err,os.Open(src),wf)
defer r.Close()
...
collect(&err,io.Copy(w, r),wf)
ghost commented 5 years ago

Since collect returns the zeros of the function calls but does return from the function wouldn't this mean that if there is an error the flow would continue but with zeros?

No, it skips if err is not nil. But if you stop using collect, but with the error-prone valuable, yes, it continue with zero value.

Meaning if os.Open returned an error, then r would be a zero. Meaning the defer r.Close() would attempt to close a zero, maybe nil. Then io.Copy would try to copy from a zero?

r := collect(&err,os.Open(src),wf)
defer r.Close()
...
collect(&err,io.Copy(w, r),wf)

Yes, in current form, defer r.Close() run as defer nil.Close() while the nil is a typed nil. While this is ok in os.File case, it is not very desirable. So I write in the proposal to discuss maybe it is better to allow collect(&err, defer r.Close(), wf). And No, if r is nil, it means there is an error collected, so err is not nil - thus, there will be NO attempt to run io.Copy(w, r), so io.Copy will NOT try to copy from zero.

mvndaai commented 5 years ago

@leafbebop thanks for that clarification. What worries me is that after the first collect all other functions would also need a collect because if the if err != nil { skip }. It would even be necessary on functions that cannot return an error because of the zero values.

ghost commented 5 years ago

@mvndaai I understand that. But as I say in the proposal, collect only collects error. If the logic becomes too complex to be written in collect, I think it is time to actually handle the error.

Unlike other proposals (try or others), collect does not aim to replace if err != nil, rather, it aims to accompany it.

ianlancetaylor commented 5 years ago

In order to use this, essentially everything after the first call to collect has to be a call to collect. Or has to check err != nil in which case collect didn't help us at all.

Although collect does not itself return or cause any control flow, in effect it completely obscures the control flow, because when err becomes non-nil, all future function calls do not occur, even though it seems that they do. This will also likely cause execution to be somewhat slower.

Even in the base case, in practice every line will start will collect, making it harder to see what the function does.

If you accidentally forget a second or subsequent collect, and just call a function directly, any number of errors could occur.

-- for @golang/proposal-review