go: proposal: Go 2: simplify error handling - error passing with "pass"
Introduction
For the most part, I like the simplicity of error handling in Go, but I would very much like a less verbose way to pass errors.
Passing errors should still be explicit and simple enough to understand. This proposal builds on top of the many existing proposals and suggestions, and attempts to fix the problems in the proposed try built-in see design doc. Please read the original proposal if you haven’t already some of the questions may already be answered there.
Would you consider yourself a novice, intermediate, or experienced Go programmer?
- I have been using Go almost exclusively for the past two years, and I have been a developer for the past 10 years.
What other languages do you have experience with?
- C, Java, C#, Python, Javascript, Typescript, Kotlin, Prolog, Rust, PHP … and others that I don’t remember.
Would this change make Go easier or harder to learn, and why?
- It may make Go slightly harder to learn because its an additional keyword.
Who does this proposal help, and why?
- This proposal helps all Go developers in their daily use of the language because it makes error handling less verbose.
Proposal
What is the proposed change?
Adding a new keyword pass.
pass expr
Pass statements may only be used inside a function with at least one result parameter where the last result is of type error.
Pass statements take an expression. The expression can be a simple error value. It can also be a function or a method call that returns an error.
Calling pass with any other type or a different context will result in a compile-time error.
If the result of the expression is nil, pass will not perform any action, and execution continues.
If the result of the expression != nil, pass will return from the function.
- For unnamed result parameters,
passassumes their default “zero” values. - For named result parameters,
passwill return the value they already have.
This works similar to the proposed try built-in.
Is this change backward compatible?
Yes.
Show example code before and after the change.
A simple example:
before:
f, err := os.Open(filename)
if err != nil {
return ..., err
}
defer f.Close()
after:
f, err := os.Open(filename)
pass err
defer f.Close()
In the example above, if the value of err is nil. it is equivalent to calling pass in the following way (does not perform any action):
pass nil
Consequently, the following is expected to work with pass (passing a new error)
pass errors.New("some error")
Since pass accepts an expression that must be evaluated to an error . We can create handlers without any additional changes to the language.
For example, the following errors.Wrap() function works with pass.
// Wrap returns an error annotating err with a stack trace
// at the point Wrap is called, and the supplied message.
// If err is nil, Wrap returns nil.
func Wrap(err error, message string) error {
if err == nil {
return nil
}
err = &withMessage{
cause: err,
msg: message,
}
return &withStack{
err,
callers(),
}
}
Because Wrap returns nil when err == nil, you can use it to wrap errors:
f, err := os.Open(filename)
pass errors.Wrap(err, "couldn't open file")
the following does not perform any action (because Wrap will return nil):
pass errors.Wrap(nil, "this pass will not return")
You can define any function that takes an error to add logging, context, stack traces … etc.
func Foo(err Error) error {
if err == nil {
return nil
}
// wrap the error or return a new error
}
To use it
f, err := os.Open(filename)
pass Foo(err)
pass is designed specifically for passing errors and nothing else.
Other examples:
(Example updated to better reflect different usages of pass)
Here’s an example in practice. Code from codehost.newCodeRepo() (found by searching for err != nil - comments removed)
This example shows when it’s possible to use pass, and how it may look like in the real world.
before:
func newGitRepo(remote string, localOK bool) (Repo, error) {
r := &gitRepo{remote: remote}
if strings.Contains(remote, "://") {
var err error
r.dir, r.mu.Path, err = WorkDir(gitWorkDirType, r.remote)
if err != nil {
return nil, err
}
unlock, err := r.mu.Lock()
if err != nil {
return nil, err
}
defer unlock()
if _, err := os.Stat(filepath.Join(r.dir, "objects")); err != nil {
if _, err := Run(r.dir, "git", "init", "--bare"); err != nil {
os.RemoveAll(r.dir)
return nil, err
}
if _, err := Run(r.dir, "git", "remote", "add", "origin", "--", r.remote); err != nil {
os.RemoveAll(r.dir)
return nil, err
}
}
r.remoteURL = r.remote
r.remote = "origin"
} else {
if strings.Contains(remote, ":") {
return nil, fmt.Errorf("git remote cannot use host:path syntax")
}
if !localOK {
return nil, fmt.Errorf("git remote must not be local directory")
}
r.local = true
info, err := os.Stat(remote)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("%s exists but is not a directory", remote)
}
r.dir = remote
r.mu.Path = r.dir + ".lock"
}
return r, nil
}
after:
func newGitRepo(remote string, localOK bool) (Repo, error) {
r := &gitRepo{remote: remote}
if strings.Contains(remote, "://") {
var err error
r.dir, r.mu.Path, err = WorkDir(gitWorkDirType, r.remote)
pass err
unlock, err := r.mu.Lock()
pass err
defer unlock()
if _, err := os.Stat(filepath.Join(r.dir, "objects")); err != nil {
if _, err := Run(r.dir, "git", "init", "--bare"); err != nil {
os.RemoveAll(r.dir)
pass err
}
if _, err := Run(r.dir, "git", "remote", "add", "origin", "--", r.remote); err != nil {
os.RemoveAll(r.dir)
pass err
}
}
r.remoteURL = r.remote
r.remote = "origin"
} else {
if strings.Contains(remote, ":") {
pass fmt.Errorf("git remote cannot use host:path syntax")
}
if !localOK {
pass fmt.Errorf("git remote must not be local directory")
}
r.local = true
info, err := os.Stat(remote)
pass err
if !info.IsDir() {
pass fmt.Errorf("%s exists but is not a directory", remote)
}
r.dir = remote
r.mu.Path = r.dir + ".lock"
}
return r, nil
}
What is the cost of this proposal? (Every language change has a cost).
- How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?
Because this is a new keyword, I’m not sure how much some of these tools would be affected, but they shouldn’t need any significant changes.
- What is the compile time cost?
Compile time cost may be affected because the compiler may need to
perform additional optimizations to function or method calls used with pass.
- What is the run time cost?
This depends on the implementation. Simple expressions like this:
pass err
should have equivalent runtime cost to the current err != nil.
However, function or method calls will add run time cost and this will largely depend on the implementation.
Can you describe a possible implementation?
For the simple case, the compiler may be able to expand pass statements
pass err
to
if err != nil {
return result paramters ...
}
For function or method calls there maybe a better way to do it possibly inline these calls for common cases?
How would the language spec change?
The new keyword pass must be added to the language spec with a more formal definition.
Orthogonality: how does this change interact or overlap with existing features?
In some cases, pass may overlap with a return statement.
func someFunc() error {
if cond {
pass errors.New("some error")
}
...
instead of
func someFunc() error {
if cond {
return errors.New("some error")
}
...
It may not be clear which keyword should be used here.
pass is only useful in these cases when there is multiple result parameters.
func someFunc() (int, int, error) {
if cond {
pass errors.New("some error")
}
...
instead of
func someFunc() (int, int, error) {
if cond {
return 0, 0, errors.New("some error")
}
...
(Edited to make example more clear)
Is the goal of this change a performance improvement?
No
Does this affect error handling?
Yes, here is the following advantages of pass and how it may differ from other proposals.
Advantages to using pass
passis still explicit.- Supports wrapping errors and using simple handlers without new keywords such
handle… etc. - it makes it easier to scan code. Avoids confusing
err == nilwitherr != nil. Sincepassonly returns when error is notnilin all cases. - should work fairly easily with existing error handling libraries.
- should work with breakpoints
- much less verbose.
- Fairly simple to understand.
Is this about generics?
No
Edit: Updated to follow the template
About this issue
- Original URL
- State: open
- Created 4 years ago
- Reactions: 86
- Comments: 44 (10 by maintainers)
Why not go one step further and do something like:
instead adding more complexity to the syntax, I’d propose to implement a typical
try {} catch (err) {}so we don’t need to capture the function return errors, but they will go directly to the catch if any function in the try returns one.Example:
Before:
After:
This just saves us time and lines of code, whenever we have to write multiple
err != nilchecksAs an experienced developer reading a function I am not familiar with, I commonly skim down the page looking at its control flow structure. I look for loops, branches, and return points to get a high level idea of the type of function I am looking at. To identify the return points I currently only need to look for two words, “return” and “panic”. The first is ubiquitous and the second is rare, which means in a practical sense I can quickly scan for lines starting with the word “return.”
The proposed keyword “pass” introduces a new word to look for that would also be ubiquitous. I believe that is a considerable cost. The alternatives of “return if” or “return?” do not incur that cost.
I don’t know how to pronounce “return?” in my mind or when reading code to a colleague, and adding a keyword that contains a non-alpha character is stylistically out of step with the rest of the language.
“return if” seems more natural to me. It has a clear pronunciation when reading and is reminiscent of the “else if” clause already present in the language. But “return if” has the cost that, unlike “if” or “else if”, it takes an error expression instead of a boolean expression. But that cost seems lower than the costs of “pass” or “return?” to me.
Is the cost of adding “return if” worth it to save two lines and maybe some explicit zero values? Maybe. I am generally skeptical of the argument that the repetition of
if err != nil { return ... }is a large burden to reading Go code. But this proposal has perhaps the best value/cost ratio we’ve seen so far.I really like the
return if errversion of this idea: it directly summarizes the three lines of code that it replaces, and doesn’t introduce any new keywords, logic or anything else other than addressing the one consistent sore point with the existing situation: it takes a lot of space and eyeball friction for such a high-frequency expression. Also, I’ve definitely mistypederr == nilinstead oferr != nilseveral times without noticing, so avoiding the need for that is a bonus.You’ve already tried to change the language to fix this problem, so it seems that there is a general agreement that it is worth fixing, and this is just about as minimal, yet impactful, a change as could be made. If it survives the flamethrowers, it would probably build a good feeling in the community that there really is a willingness to fix the few existing sore points in the language…
How about simply using
returnto pass errors:returnreplacesif err!=nil {return ...,err}, without an explicit declaration oferrvariable. If you need to wrap the error, handle it, or print, log, etc. you would still use the current error handling boilerplate.I have to assume this has been proposed before but I did not see it.
Why not take a lesson from
ifandforand define a version ofreturnthat takes a second optional arg, a boolean condition? Return is already special in that there exists a zero-argument form with named returns.For example; we often write code that passes the error up the stack:
if we allow
returnto be used asthen you have a short form that short circuits and returns from the function. It doesn’t change the semantics of the return expression to the left of the condition and defers etc work as before. You can mentally read this form of
returnasreturn if.There is added potential to write expressions such as
though this last form may be a step too far.
zero argument returns continue to work, assuming the use of named arguments.
Net introduces no new symbols or keywords and is consistent with
forandifBeen thinking about this over the weekend and I was initially attracted to the idea but as I have processed the comments the idea of magically inlining parts of a wrapped function or eating the penalty of value creation and throwing it away feel untenable.
But there’s clearly a problem that needs to be solved otherwise we wouldn’t be banging our heads against it ad infinitum.
@bserdar’s comment doesn’t seem too off base in the service of trying to find a solution even if it is not direct feedback on the solution at hand.
But the overloading of
returnis a non-starter for me and after reviewing the linked issues, they are all just kinda awkward syntax wise (!!and?don’t read naturally to my eye). I have been playing with the idea of using a caret (^) as a sentinel value very similar to the underscore (_) to eliminate much of the boilerplate without buying new keywords and flow control or overloading semantics. My thinking is that the underscore means “table the value” and the caret means “send the value back up”.Looking at xbit’s example in the proposal, here is an equivalent with less trade-offs and language impact…
before:
after:
it is returning the error up to the caller, and given that we already have a keyword that means
returnit seems like it makes sense to use that, right?Given that the language already has two forms of
returnlogic (named vs. unnamed), adding a third seems reasonable. The named form already does some “magic” by not directly listing the values returned. This proposed form just adds an additional, logical case that says if you’re returning without achieving the mission of the function, return zero values.return?maybe seems too “ternary” for the Go language?return ifseems like a natural way to say “conditional return” using the existing language elements…It seems that
pass vrequiresvto have typeerrorand is shorthand forIt’s unfortunate that error wrapping requires calling the function even when the error is
nil, but on the other hand it would not be too hard for the compiler to check whether the wrapper function starts withand pull those lines into caller without inlining the whole function.
The name
passis certainly used as a variable name in existing code (e.g., https://golang.org/src/crypto/sha512/sha512_test.go#L664), and that code would break.Also the name
passdoesn’t seem to really describe the operation here. The word “pass” often means to pass over something, but in this case we are–passing it up to the caller? Passing over the rest of the function? I’m not sure.One possibility would be to use
return?orreturn ifinstead ofpass.As you know, there has been a lot of discussion about error handling in Go. Is this language change big enough for the problem? It saves about six tokens and two lines. Is that worth the cost of a language change?
Seems slightly unfortunate that we always have to call the
errors.Wrapfunction even when there is no error.I don’t know if this is necessary, actually. If any extra code needs to be run, I think that it’s fine to just use
if err != nil { ... }like you would now. I think that a shorter syntax is fine if it only covers the common case of doing nothing but returning a potentially modifiederr. In fact, it might be better if it does simply so that it doesn’t completely supplant the existing syntax and introduce non-orthogonal features.That being said, I do not like the
passsyntax at all. It introduces a new keyword, which breaks backwards compatibility, it’s hard to read when doing anything to the error before returning, and it doesn’t solve the basic issue of requiring new variables for every call that might return an error.Honestly, I’m leaning more and more towards some kind of limited macro system to deal with this. If a macro system could be sufficiently powerful to allow you to wrap an arbitrary call that returns
T, error, and that call is itself used in a larger expression, and the usage of the macro causes a return of a modified error when the error isn’tnil, it solves all of the issues. If the user can define the handler macros both inline and globally, then there could be clean, reusable utility macros for common cases and more specific ones defined inline for more specific cases. For example,And yes, I am aware that this actually does allow arbitrary extra code, despite my first paragraph up above.
I dont like
passoverlapping withreturn. I thinkpassshould be used in a specific context and in a uniform way.Here a few other ideas:
However, i think we have to think about 3 things when handling errors:
The pass solution wont handle the 3rd case (and depending of the chosen solution, not even the 2nd), and another solution would have to come in. And maybe go community would not like many different ways to handle errors.
That’s why i think a catch solution like this would be more appropriate, because it would give us a single, uniform way to handle errors in all those cases.
The cost of an error expression instead of a boolean expression after
return ifis high in my opinion. It can be a bit confusing because of theifkeyword.return if nil, sounds like saying return if the result of the expression isnilbut that statement does the opposite. Which is why I think “pass”, “return?” or something else may be better.I think several comments tried to address this: the main complaint is that the current solution is a) very high frequency; and b) takes 3 lines. Doing the math, eliminating 2 lines would save a lot of lines over the entire Go codebase (someone could presumably compute exactly how many – it might be a remarkably large number – anyone familiar with that Go language analysis tool that someone posted a few months back on the go-nuts list?).
this is why I favor the
return ifsyntax because it minimizes this concern: it is just a shorthand way of expressing the exact same control flow that everyone is familiar with. Its only point is to save those 2 lines!The compiler optimization @xbit mentions above is just that: a compiler optimization, but not fundamentally a new flow control.
The more I think about it, the more I like the way errors are currently handled in Go.
IMO,
return?sounds great in theory, but in practice, I have a feeling it’ll make reading the code harder.Not just that, but ignoring other use cases, for error handling, all this is doing is removing the need for writing
if err != nil {and}.Let’s say that this is sufficient in term of error handling and we decide to go with
return?orreturn if, then we’re met with the fact that since we’re using something as generic as the keywordreturn,return?will need to support returning multiple values - not just one.To do that,
return?would need to have an extra argument - a condition, which like I said earlier, will most likely make the code harder to read.Unlike what @DeedleFake mentioned, I don’t think the default behavior should be checking if the first argument is nil, because if
return?is to work with values other thanerror, then what happens with values that cannot be nil (i.e. string)?This leaves us with the following:
I think that this is a downgrade from the existing:
Even though there’s more LoC, at least there’s no confusion, and the number of returned values match with the function signature.
Also, the examples used for the
return?condition all have a conveniently small conditions, but take something like this as example:With
return?, we’d have this instead:It looks nice, but the lines are quite a bit longer - and there’s just two values being returned.
Personally, with named parameters, it’d look even better:
But I digress.
That said, I think the suggestion for
passhas its merits, but more importantly, it’s targeted at error handling, whereasreturn?has a wider implications that also happens to be something that can be leveraged for error handling.As @ianlancetaylor mentioned, I also think that the word
passmight be inappropriate for this use case.passdoes not convey “pass this value to the caller if the value passed is not nil”.assertorcheckwould make more sense for me, but even these two don’t feel explicit enough to me.Your version of WriteFile is wrong. While the original function will always close the file, your version will return if there is an error in f.Write or n<len(data) without closing the file. You could use defer, but then you will miss the error returned by f.Close. At the end, I think you would only be able to replace thee first return, so this may not be the most compelling example.