Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: spec: new builtin: handle #69045

Open
jimmyfrasche opened this issue Aug 23, 2024 · 8 comments
Open

proposal: spec: new builtin: handle #69045

jimmyfrasche opened this issue Aug 23, 2024 · 8 comments
Labels
error-handling Language & library change proposals that are about error handling. LanguageChange Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal
Milestone

Comments

@jimmyfrasche
Copy link
Member

What

handle must be deferred and only in a function whose last return value is type error. For brevity, I'll refer to this value as "the error" of the function.

handle takes an argument of type assignable to func(error) error. For brevity, I'll refer to this argument as an "error transformer".

If the error is nil, the error transformer is not called. If the error is not nil, the error transformer is called and its result replaces the error. Essentially:

if err != nil {
  err = transform(err)
}

Why?

This allows error handling routines to be written as library code. For example

func wrap(msg string) func(error) error {
  msg += ": %w"
  return func(err error) error {
    return fmt.Errorf(msg, err)
  }
}

which can be used like

defer handle(wrap("additional context"))

How?

This can be done via a macro expansion like mechanism in the compiler.

Given

func example() error {
  defer handle(wrap("oops"))
  //...
}

the compiler can treat it as if the code was

func example() (the_error error) {
  defer handle_impl(&the_error, wrap("oops"))
  //...
}
func handle_impl(err *error, f func(error) error) {
  if *err != nil {
    *err = f(*err)
  }
}

Alternatives

This could just be a regular function like:

package errors
func Handle(err *error, f func(error) error) { //...

That has two downsides:

  1. the invocation is even longer
  2. it's up to the user to pass the correct error which requires naming the return when otherwise unnecessary and making sure that name doesn't get shadowed.

A builtin obviates these, making it easier to use and thus more likely to be used.

This could be a keyword like

handle func(err error) error {
  return fmt.Errorf("oops: %w", err)
}

which is obviously much better but not backwards compatible.

A func returnedError() *error builtin that returns a pointer to the error of the deferring function. This is more powerful. You could use it to write handle and other things. It's a bit too magical and harder to use.

@jimmyfrasche jimmyfrasche added LanguageChange Suggested changes to the Go language Proposal error-handling Language & library change proposals that are about error handling. labels Aug 23, 2024
@gopherbot gopherbot added this to the Proposal milestone Aug 23, 2024
@ianlancetaylor ianlancetaylor added the LanguageChangeReview Discussed by language change review committee label Aug 23, 2024
@ianlancetaylor
Copy link
Contributor

CC @jba for experience with doing this explicitly in pkgsite code.

@jba
Copy link
Contributor

jba commented Sep 5, 2024

I use the explicit function in all my code now. I use this function:

// Wrap wraps *errp with the given formatted message if *errp is not nil.                                                                    
func Wrap(errp *error, format string, args ...any) {                                                                                         
        if *errp != nil {                                                                                                                    
                *errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp)                                                            
        }                                                                                                                                    
} 

and call it like this:

func Frob(ctx context.Context, n int) (err error) {
    defer Wrap(&err, "Frob(ctx, %d)", n)
    ...
}

I don't find that to be a signficant burden, because I only use it for "important" functions. That seems to be about 10% of all functions, based on the pkgsite code (actual numbers: 2,292 functions, 263 calls to Wrap). And maybe a quarter of those functions already need named return types for documentation.

I also very rarely need to transform an error, just to add some context to get an approximate call trace.

So I don't think adding a built-in function is worth it.

@jimmyfrasche
Copy link
Member Author

@jba your example could be added to fmt like

defer handle(fmt.Wrapf("Frob(ctx, %d)", n))

That's probably what 80%-90% of the uses of handle would be in practice. (That's not to say that handle isn't useful in other cases just that most of the time that's the only handling anyone needs!)

The next most common case would probably be logging like:

defer handle(logError)

Note that this is reacting to the error not transforming it but it can still be written as a transformer:

func logError(err error) error {
  doTheLogging(err) // reaction
  return err // identity transform
}

As errors are just values so are error transformers just functions.

We could just add functions to fmt and slog that take a *error but those would be less useful without handle since they require more ceremony to use and specifically one that people are wary to undertake as shadowing err can cause issues. For logging sometimes you just want to drop a logging statement in temporarily. That's trivial with handle but less so if you have to also change the function signature.

One thing I've run into a lot of times is that I'm working with a lot of io and I want to return on an error but I want to return nil on io.EOF and I need to do that in a lot of places. With this proposal that would just be

defer handle(func(err error) error {
  if err == io.EOF {
    return nil
  }
  return err
})

and even shorter with #21498:

defer handle(err => {
  if err == io.EOF {
    return nil
  }
  return err
})

I've written that using defer but most of the time it's too much bother and I just add the if err == io.EOF multiple times. Sometimes I'll write the real code in an unexported func and have a wrapper like

func Real() error {
  err := realImpl()
  if err == io.EOF {
    err = nil
  }
  return err
}

because it's less bother than dealing with all the stuff handle() takes care of. And with handle(), I could even write it once in an unexported function and use it in multiple functions in the same package.

This goes for anything you'd want to do the same thing in multiple error paths but that I've experienced most often in this one case.

It's true that handle()'s not doing much but it is reducing friction enough to make it simple to add some bulk error handling to a function, even if most of the time it'll just be logs or wrappers. Maybe you'd use it more often and in less important functions if it were simpler to do.

@x0wllaar
Copy link

x0wllaar commented Sep 23, 2024

I think that a more generic func returned[T any]() []**T, that, in a deferred function, returns a slice/array of pointers to pointers to all of the returned values of the deferred function will be magical, but extremely powerful for a lot of use cases.

It could be used to implement handle():

func Handle(transformer func (error) error){
    retVals := returned()
    retErr := **(retVals[len(retVals) - 1]).(error)
    if retErr == nil {
        return
    }
    newErr := transformer(retErr)
    *retVals[len(retVals) - 1] = &newErr
}

@jimmyfrasche
Copy link
Member Author

That would require many changes to the language before you could represent and use the result. You could narrow that to something that returns a *error that points to the last error return, as noted in the initial post.

@x0wllaar
Copy link

x0wllaar commented Sep 23, 2024

I've modified my original comment. What other changes to the language will be needed compared to just returning *error?

@jimmyfrasche
Copy link
Member Author

You would need to define what makes all of those lines of code legal. For example, you cannot have a slice where each value is a different type. That is a very complicated thing and not on topic for this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
error-handling Language & library change proposals that are about error handling. LanguageChange Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal
Projects
None yet
Development

No branches or pull requests

6 participants