-
Notifications
You must be signed in to change notification settings - Fork 18k
proposal: spec: try-handle keywords for reducing common error handling boilerplate #73376
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
Comments
In the original
I think your proposal basically addresses those problems. For Go code today, there is a balance:
I think that balance today is part of what makes Go error handling work in practice, even though Go does not have an Option type, matching, the ability to force handling of certain errors, and so on. I think your proposal largely keeps both sides of that balancing act true, and FWIW, I think the placement of the Finally, in the original |
Regardless of the merits/demerits of this proposal, wouldn't the addition of keywords in the language constitute a breaking change? I thought that the Go team's intention was to avoid the need for Go 2.0 at all costs. Is the plan to gate such changes behind the |
Rather than reserved keywords, I wonder if My initial guess would be it's at least plausible that either this current proposal or a slight modification to it could be made to coexist, for example, in the same file as a current Though maybe not, including there can be a problem of a parsing ambiguity or lookahead requirement. In short, I don't know if it's actually possible here, but I do know that there are people who are clever about working through how to evolve the spec in a backwards compatible way if it's worthwhile, though perhaps it would require a modification to this proposal. |
Personally I prefer the keyword form; The bar for adding new keywords should be high, but I don't think it should be out of the question. It can be gated behind the Go language version in |
or the compiler can be made smart enough to see |
I think this is a sufficiently good reason to add keywords. It does add to the effort of rolling the feature out, but there's nothing insurmountable there. Perhaps there are other choices for the names that will require fewer rewrites. That can be studied further into the process. Keywords are not strictly necessary for this proposal to work, however. It could be done with new operators. Something like Regardless, this is of lesser importance than the semantics of the feature. If the semantics are good then the rest is a paint job. |
If we allow only one |
Would it be possible for func ReadFileDefault(path, def string) error {
_, err := try os.ReadFile(path) handle func(err error) error {
if errors.Is(err, os.ErrNotExist) {
// initialize file and continue
os.WriteFile(path, []byte(def), 0o644)
return nil
// does this return nil from ReadFileDefault, or continue ?
}
// ...
}
// ...
} |
Hi @paskozdilar, the proposal writeup includes:
Personally, I think that is one of the strengths of the proposal, including it could allow you to reason more easily about a function when skimming or when trying to read carefully. If you are reading some function |
I see no reason to limit it to one
If try f()
handle h and was that meant or should that have been |
@paskozdilar what @thepudds said. But also a handler is basically called like |
Bash calls |
Are you saying that it should be three keywords? or that it should be |
I don't think there need to be two ways to do it, so I would eliminate the try + handle form and just do:
If you want a particular handler for a particular line, that's what |
Enhance the role of defer, add defer on error defer on error { ... }
func ConvertAndSave(src, dst string) (err error) {
defer on error { log.Printf("failed: %v", err) }
input := os.ReadFile(src) ? "read input"
defer on error { os.Remove(dst) }
data := process(input) ? "process data"
os.WriteFile(dst, data, 0644) ? "write output"
return nil
}
data := os.ReadFile("file.txt") ? // Automatic return of errors (with implicit context)
data := os.ReadFile(path) ? "read config" // The error message automatically contains ‘read config: ...’
func RetryFetch(url string, retries int) (data []byte) {
for i := 0; i < retries; i++ {
data = HttpGet(url) ? |> func(err error) error {
if IsTimeout(err) && i < retries-1 {
log.Printf("retrying...")
return nil
}
return err
}
break
}
return
}
|
@earthboundkid try f()
try g() handle panic
try h() for cases where you want to do something a little extra for one call in the middle |
@xiaokentrl that seems more complicated, more magical, and less powerful. I don't think those are good trade offs or very much related to this proposal. I believe there have been others closer to that but do not recall the issue numbers off hand. |
func must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
func handle(errp *error) {
if v := recover(); v != nil {
if recerr, ok := v.(error); ok {
fmt.Println("handling")
*errp = recerr
}
}
}
func f() (err error) {
defer handle(&err)
f := must(os.Open("x"))
_ = f
return
} It would be interesting to have |
Hi @earthboundkid, eliminating the try + handle form feels in essence like a different proposal. In addition to what @jimmyfrasche said, it goes against being able to easily do in-place annotation or wrapping of errors, including when coming back to code after a function is first written. (See point 2. in #73376 (comment)). |
@earthboundkid in a lot of ways what you want to do without panics is basically this proposal! Check out the last section of the first post where I manually desugar the proposed constructs |
This comment has been minimized.
This comment has been minimized.
@xiaokentrl that approach is so different from what is being discussed here that you'd be better off making a separate proposal. |
In bash, Given that, I don't find "trap" to be an appropriate verb. If we were specifying a curried |
I think having both |
This proposal does seem to neatly reduce the boilerplate of both of the currently-competing conventions in the go ecosystem:
However, I feel concerned about blessing both of these patterns with a special language feature, because the ambiguity about which to use is already causing confusingly-redundant error messages at the boundaries between packages that prefer the opposite orientation. Just a few hours ago I encountered an error from a Go program that said something to the effect of:
Some of the functions in this call chain did the callee-annotates approach and others did the caller-annotates approach, and so it mentions three times that it was trying to retrieve One potentially-useful outcome of having a built-in error handling feature in the language could be to finally encode into the language whether "caller annotates" or "callee annotates" is the main idiomatic way to deal with error annotation in most cases, and then hopefully through gradual repair over time we can eventually be free of these unnecessarily-long error messages that typical Go software is prone to generate today. For that reason, I'd personally prefer to choose either (I know from previous discussions that this question of who should be responsible for annotating an error has strong opinions on both sides, so I'd advise against having yet another iteration of the argument about whether "caller annotates" or "callee annotates" is better in the comments of this issue. I have my own preference of course, but more important to me is that there is one generally-adopted answer, regardless of which it is.) |
|
At any rate, I don't think the repetitious onion error message is the result of where in a function the error is annotated. It appears to be the result of a chain of functions each including the same information in their annotations. That does not seem relevant to this class of error handling proposal. Perhaps there needs to be some better way of manipulating error values at runtime so you could tell the inner errors to keep it brief? I dunno. We're getting off topic. |
From reviewing the source code of the program that generated the error I know that at the first two of those three redundant mentions of fetching the URL are caused by the problem I described: the first one was added by the callee of a function and the second one was added by that function itself, presumably because caller and callee were written by people with different expectations. I'll concede that the third one is caused by the With that said: I'm happy to leave this here. I don't think we need to debate that specific example any further, and I agree that the problem I described has a broader scope than just what was proposed here, so is probably better discussed in another place if anything more needs to be said about it. 🤷 |
Proposal Details
This is another error handling proposal—heavily influenced by the check/handle draft and subsequent issues, especially #32437, #71203, and #69045. It introduces two keywords,
try
andhandle
.The goal is not to replace all error handling. The goal is to replace the most common case, returning an error with some optional handling. For the uncommon cases, errors are still regular values and there is still the rest of the language to deal with them.
In a function whose last return is an error,
try expr
returns early when there's an error. All other returns are the zero value of their respective types.You can handle the error before it is returned with
try expr handle expr
where the secondexpr
evaluates to a handler function.There are three kinds of handler function
err = h(err)
h(err)
h()
The first allows you to transform the error, for example, to wrap it or return a different value entirely. The last two allow you to respond to an error without modifying it, for example, to log the error or clean up an intermediary resource.
Here's the previous example with a handler,
h
:The handler is only called when the error returned by
f
is notnil
. The handler is only ever passed the error.There is no way for a handler to stop the function from returning. It may only react.
Often the same handler logic can be applied to many errors. Rather than repeating the handler on every
try
, a handler may be placed on the defer stack withdefer handle expr
.While the
try expr handle expr
only evaluates the handler when thetry
expression returns an error, thedefer handle expr
evaluates the handler whenever a nonnil error is returned, so it can also be used withouttry
in functions that return an error.A function that does not return an error may use
try
andhandle
but only when the last handler executed returns nothing. Since there is no error to return, we need a handler that takes responsibility for what happens with the error in lieu of returning it. Once such a handler is in place, everything else behaves as it does for a function that returns an error: any handler may be deferred or used withtry
andtry
may be used withouthandle
.This can be as simple as adding one of these to the top of the function:
defer handle panic
defer handle log.Fatal
defer handle t.Fatal
This same logic also allows package level variables to use
try
–handle
as long as the handler does not have a return, for example:try
, at least initially, is limited to assignments and expression statements.The following sections should help clarify the proposal but are not strictly necessary to read. However, if you have a question, you may want to at least skim them to see if it is answered there.
Examples
Possible changes to std
As handlers are simple functions, libraries and frameworks may define their own as appropriate. These are examples of some that could be added to std in follow up proposals. How they would shorten the example code above is left as an exercise to the reader.
One of the most common handlers needed is one that wraps an error with some additional context. Something like the below should be added to
fmt
.Another common case is ignoring a specific sentinel (return
nil
onio.EOF
orfs.ErrExist
for example) and this can be simplified with something like:Functions such as
template.Must
andregexp.MustCompile
may be deprecated in favor of the newtry f() handle panic
idiom.try–handle rewritten into existing language
Let's take the example below and rewrite it as equivalent code in the current Go language to show the nuts and bolts of what the new code does. In the below
f
returns(int, error)
andh1
andh2
arefunc(error) error
.The variable names introduced by the rewrite are unimportant.
This section is not meant to describe the implementation. It is only so you can see how
try
andhandle
operate by way of semantically equivalent code in the current language.↓
Notes:
defer handle
would manage this transparently.defer handle
expands into a straightforward function that invokes the handler if the error isnil
.try
expands into the basicif err != nil { return
... boilerplate and atry
–handle
simply inserts an invocation of the handler after thenil
check and before thereturn
.Functions that do not return an error work largely the same way.
↓
Notes:
try
is largely similar except we assign to our special error before returning instead of returning the error. If this were atry
–handle
we would invoke the handler before this.The text was updated successfully, but these errors were encountered: